agentmux_srv\backend\wcore/
mod.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Wave Core: application coordinator for storage + pub/sub.
6//! Port of Go's pkg/wcore/wcore.go + window.go + workspace.go + block.go.
7//!
8//! Orchestrates Store mutations with WPS event publishing.
9
10mod block;
11mod dnd;
12mod event;
13mod tab;
14mod window;
15mod workspace;
16
17// Re-export all public APIs so callers can continue using `wcore::function_name`.
18pub use block::*;
19pub use dnd::*;
20#[allow(unused_imports)]
21pub use event::*;
22pub use tab::*;
23pub use window::*;
24pub use workspace::*;
25
26use uuid::Uuid;
27
28use super::storage::store::Store;
29use super::storage::StoreError;
30use super::obj::*;
31
32// ---- Layout action types (match Go) ----
33
34pub const LAYOUT_ACTION_INSERT: &str = "insert";
35pub const LAYOUT_ACTION_INSERT_AT_INDEX: &str = "insertatindex";
36pub const LAYOUT_ACTION_REMOVE: &str = "remove";
37pub const LAYOUT_ACTION_CLEAR_TREE: &str = "cleartree";
38pub const LAYOUT_ACTION_REPLACE: &str = "replace";
39pub const LAYOUT_ACTION_SPLIT_HORIZONTAL: &str = "splithorizontal";
40pub const LAYOUT_ACTION_SPLIT_VERTICAL: &str = "splitvertical";
41
42// ---- Core operations ----
43
44/// Ensure initial data is present in the store.
45/// Creates a default Client, Window, Workspace, Tab if the store is empty.
46/// Returns `true` if this is a first launch (client was just created).
47pub fn ensure_initial_data(store: &Store) -> Result<bool, StoreError> {
48    let clients = store.get_all::<Client>()?;
49
50    if !clients.is_empty() {
51        // Already initialized
52        let client = &clients[0];
53        if client.tempoid.is_empty() {
54            let mut client = client.clone();
55            client.tempoid = Uuid::new_v4().to_string();
56            store.update(&mut client)?;
57        }
58        // Check and fix windows
59        for window_id in &client.windowids {
60            window::check_and_fix_window(store, window_id)?;
61        }
62        return Ok(false);
63    }
64
65    // First launch: create client + window + workspace + tab
66    let first_launch = true;
67
68    // Go inserts client first (version 1), then updates TempOID (version 2).
69    // We mirror that to keep the version counter in sync.
70    let mut client = Client {
71        oid: Uuid::new_v4().to_string(),
72        windowids: vec![],
73        tempoid: String::new(),
74        meta: MetaMapType::new(),
75        ..Default::default()
76    };
77
78    store.insert(&mut client)?;
79
80    // Separate update for TempOID (matches Go's version 2 update)
81    client.tempoid = Uuid::new_v4().to_string();
82    store.update(&mut client)?;
83
84    // Create starter workspace
85    let ws = create_workspace(store, "Starter workspace")?;
86
87    // Create window pointing to workspace
88    let mut win = create_window(store, &ws.oid)?;
89
90    // Seed the window's display name (drives the OS/taskbar title) from
91    // AGENTMUX_WINDOW_NAME if the launcher/caller set it. This lets an agent
92    // bring up a recognizable instance, e.g. `AGENTMUX_WINDOW_NAME="repro #1503"
93    // task dev`. Only applies to a fresh instance's first window (this
94    // initial-data path); the runtime `SetWindowName` verb renames later.
95    // See SPEC_AGENT_API_FIRST_CLASS_SURFACE_2026_06_17.md §4.4.
96    if let Ok(name) = std::env::var("AGENTMUX_WINDOW_NAME") {
97        let name = name.trim();
98        if !name.is_empty() {
99            win.meta.insert(
100                "window:displayname".to_string(),
101                serde_json::json!(name.chars().take(64).collect::<String>()),
102            );
103            store.update(&mut win)?;
104        }
105    }
106
107    // Update client with window ID
108    client.windowids.push(win.oid.clone());
109    store.update(&mut client)?;
110
111    // Create initial tab in workspace (pinned, matching Go's isInitialLaunch=true)
112    let tab = create_tab_with_opts(store, &ws.oid, "", true)?;
113
114    // Seed the default 3-pane launch layout (agent + sysinfo + swarm). Shared
115    // with the new-window path so "Open another window" matches first launch.
116    seed_default_layout(store, &tab.oid)?;
117
118    Ok(first_launch)
119}
120
121/// Seed a tab with the default 3-pane launch layout (agent + sysinfo + swarm):
122///
123/// ```text
124///   ┌────────────────┬──────────────┐
125///   │                │   sysinfo    │  size 2 of 10 ≈ 20%
126///   │     agent      ├──────────────┤
127///   │    (tall)      │              │
128///   │                │    swarm     │  size 8 of 10 ≈ 80%
129///   └────────────────┴──────────────┘
130///        50% width         50% width
131/// ```
132///
133/// Shared by first-launch bootstrap (`ensure_initial_data`) and the new-window
134/// path (`server::service`'s `CreateWindow` with an empty workspace) so "Open
135/// another window" gets the same starter layout instead of opening blank
136/// (regression — see docs/retro/retro-blank-new-window-2026-06-21.md). The tab
137/// must already exist with a valid `layoutstate`; both `create_tab_with_opts`
138/// and the reducer's `apply_tab_created` provision one.
139///
140/// `rootnode` is built directly rather than queued via `pendingbackendactions`:
141/// the frontend reducer races when draining multiple insert+split actions in one
142/// cycle (`layoutPersistence.ts` + `layoutNodeModels.ts::getNodeByBlockId`); a
143/// pre-built tree skips the reducer entirely and just renders.
144pub(crate) fn seed_default_layout(store: &Store, tab_id: &str) -> Result<(), StoreError> {
145    let tab = store.must_get::<Tab>(tab_id)?;
146
147    let mut agent_meta = MetaMapType::new();
148    agent_meta.insert("view".to_string(), serde_json::json!("agent"));
149    let agent_block = create_block(store, &tab.oid, agent_meta)?;
150
151    let mut sysinfo_meta = MetaMapType::new();
152    sysinfo_meta.insert("view".to_string(), serde_json::json!("sysinfo"));
153    let sysinfo_block = create_block(store, &tab.oid, sysinfo_meta)?;
154
155    let mut swarm_meta = MetaMapType::new();
156    swarm_meta.insert("view".to_string(), serde_json::json!("swarm"));
157    let swarm_block = create_block(store, &tab.oid, swarm_meta)?;
158
159    write_default_three_pane_layout(
160        store,
161        tab_id,
162        &agent_block.oid,
163        &sysinfo_block.oid,
164        &swarm_block.oid,
165    )
166}
167
168/// Write the default 3-pane launch layout (`agent | [sysinfo / swarm]`) into
169/// `tab_id`'s `LayoutState`, wrapping three block IDs that the caller has
170/// ALREADY created.
171///
172/// Split out of `seed_default_layout` for the 2nd-window-tear-off desync fix
173/// (#1681). `seed_default_layout` creates its blocks store-only via
174/// `create_block`, which is correct for first-launch (`ensure_initial_data`
175/// runs before bootstrap loads SQLite into the in-memory reducer `srv_state`).
176/// But the post-bootstrap "open another window" path (`service.rs` CreateWindow)
177/// must create the blocks THROUGH THE REDUCER so they also land in `srv_state`;
178/// otherwise the new window's blocks live only in SQLite, the frontend renders
179/// them, and `TearOffBlock` rejects them as "block not found" (the reducer never
180/// saw them). Both callers share this one function for the tree shape so the
181/// layout can never drift between the two paths.
182pub(crate) fn write_default_three_pane_layout(
183    store: &Store,
184    tab_id: &str,
185    agent_block_id: &str,
186    sysinfo_block_id: &str,
187    swarm_block_id: &str,
188) -> Result<(), StoreError> {
189    let tab = store.must_get::<Tab>(tab_id)?;
190
191    // Node IDs for each tree position. Leaves get their own node IDs distinct
192    // from the block IDs they wrap.
193    let agent_node_id = Uuid::new_v4().to_string();
194    let sysinfo_node_id = Uuid::new_v4().to_string();
195    let swarm_node_id = Uuid::new_v4().to_string();
196    let right_col_id = Uuid::new_v4().to_string();
197    let root_id = Uuid::new_v4().to_string();
198
199    // Phase E.4.B Phase 2 — typed LayoutNode (was inline JSON).
200    let rootnode = LayoutNode {
201        id: root_id,
202        flex_direction: FlexDirection::Row,
203        size: 10.0,
204        data: None,
205        children: vec![
206            LayoutNode {
207                id: agent_node_id.clone(),
208                flex_direction: FlexDirection::Column,
209                size: 5.0,
210                children: Vec::new(),
211                data: Some(LayoutNodeData {
212                    block_id: agent_block_id.to_string(),
213                    ..Default::default()
214                }),
215                ..Default::default()
216            },
217            LayoutNode {
218                id: right_col_id,
219                flex_direction: FlexDirection::Column,
220                size: 5.0,
221                children: vec![
222                    LayoutNode {
223                        id: sysinfo_node_id.clone(),
224                        flex_direction: FlexDirection::Row,
225                        size: 2.0,
226                        children: Vec::new(),
227                        data: Some(LayoutNodeData {
228                            block_id: sysinfo_block_id.to_string(),
229                            ..Default::default()
230                        }),
231                        ..Default::default()
232                    },
233                    LayoutNode {
234                        id: swarm_node_id.clone(),
235                        flex_direction: FlexDirection::Row,
236                        size: 8.0,
237                        children: Vec::new(),
238                        data: Some(LayoutNodeData {
239                            block_id: swarm_block_id.to_string(),
240                            ..Default::default()
241                        }),
242                        ..Default::default()
243                    },
244                ],
245                data: None,
246                ..Default::default()
247            },
248        ],
249        ..Default::default()
250    };
251
252    let mut layout = store.must_get::<LayoutState>(&tab.layoutstate)?;
253    layout.rootnode = Some(rootnode);
254    layout.focusednodeid = agent_node_id.clone();
255    layout.leaforder = Some(vec![
256        LeafOrderEntry { nodeid: agent_node_id, blockid: agent_block_id.to_string() },
257        LeafOrderEntry { nodeid: sysinfo_node_id, blockid: sysinfo_block_id.to_string() },
258        LeafOrderEntry { nodeid: swarm_node_id, blockid: swarm_block_id.to_string() },
259    ]);
260    layout.pendingbackendactions = None;
261    store.update(&mut layout)?;
262    Ok(())
263}
264
265/// Get the singleton client record.
266pub fn get_client(store: &Store) -> Result<Client, StoreError> {
267    let clients = store.get_all::<Client>()?;
268    clients.into_iter().next().ok_or(StoreError::NotFound)
269}
270
271// ====================================================================
272// Tests
273// ====================================================================
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn make_store() -> Store {
280        Store::open_in_memory().unwrap()
281    }
282
283    #[test]
284    fn test_ensure_initial_data_first_launch() {
285        let store = make_store();
286        let first = ensure_initial_data(&store).unwrap();
287        assert!(first);
288
289        // Should have created client, window, workspace, tab
290        let client = get_client(&store).unwrap();
291        assert_eq!(client.windowids.len(), 1);
292        assert!(!client.tempoid.is_empty());
293
294        let windows = store.get_all::<Window>().unwrap();
295        assert_eq!(windows.len(), 1);
296        // Window should have pos:{0,0} and winsize:{0,0} (matching Go)
297        assert_eq!(windows[0].pos.x, 0);
298        assert_eq!(windows[0].pos.y, 0);
299        assert_eq!(windows[0].winsize.width, 0);
300        assert_eq!(windows[0].winsize.height, 0);
301
302        let workspaces = store.get_all::<Workspace>().unwrap();
303        assert_eq!(workspaces.len(), 1);
304        assert_eq!(workspaces[0].name, "Starter workspace");
305        // Starter tab should be pinned (matching Go's isInitialLaunch=true)
306        assert_eq!(workspaces[0].pinnedtabids.len(), 1);
307        assert_eq!(workspaces[0].tabids.len(), 0);
308
309        let tabs = store.get_all::<Tab>().unwrap();
310        assert_eq!(tabs.len(), 1);
311        // Tab should be named "tab1" (per SPEC_TAB_GAPS_AND_NAMING_2026_04_25 —
312        // auto-generated tabs use the `tabN` convention, not the older
313        // `Untitled1` name. The test was asserting against stale-spec naming).
314        assert_eq!(tabs[0].name, "tab1");
315    }
316
317    #[test]
318    fn test_ensure_initial_data_idempotent() {
319        let store = make_store();
320        let first = ensure_initial_data(&store).unwrap();
321        assert!(first);
322
323        let second = ensure_initial_data(&store).unwrap();
324        assert!(!second);
325
326        // Should still have exactly 1 client
327        assert_eq!(store.count::<Client>().unwrap(), 1);
328    }
329
330    #[test]
331    fn test_create_and_delete_workspace() {
332        let store = make_store();
333        let ws = create_workspace(&store, "Test WS").unwrap();
334        assert_eq!(ws.name, "Test WS");
335
336        // Create tabs in workspace
337        let t1 = create_tab(&store, &ws.oid).unwrap();
338        let t2 = create_tab(&store, &ws.oid).unwrap();
339
340        let ws = get_workspace(&store, &ws.oid).unwrap();
341        assert_eq!(ws.tabids.len(), 2);
342
343        // Delete workspace cascades to tabs
344        let t1_oid = t1.oid.clone();
345        let t2_oid = t2.oid.clone();
346        delete_workspace(&store, &ws.oid).unwrap();
347        assert!(store.get::<Workspace>(&ws.oid).unwrap().is_none());
348        assert!(store.get::<Tab>(&t1_oid).unwrap().is_none());
349        assert!(store.get::<Tab>(&t2_oid).unwrap().is_none());
350    }
351
352    #[test]
353    fn test_create_and_delete_tab() {
354        let store = make_store();
355        let ws = create_workspace(&store, "WS").unwrap();
356        let tab1 = create_tab(&store, &ws.oid).unwrap();
357        let tab2 = create_tab(&store, &ws.oid).unwrap();
358
359        let ws = get_workspace(&store, &ws.oid).unwrap();
360        assert_eq!(ws.tabids.len(), 2);
361        assert_eq!(ws.activetabid, tab1.oid);
362
363        // Delete active tab — active should switch to tab2
364        delete_tab(&store, &ws.oid, &tab1.oid).unwrap();
365        let ws = get_workspace(&store, &ws.oid).unwrap();
366        assert_eq!(ws.tabids.len(), 1);
367        assert_eq!(ws.activetabid, tab2.oid);
368    }
369
370    #[test]
371    fn test_set_active_tab() {
372        let store = make_store();
373        let ws = create_workspace(&store, "WS").unwrap();
374        let _tab1 = create_tab(&store, &ws.oid).unwrap();
375        let tab2 = create_tab(&store, &ws.oid).unwrap();
376
377        set_active_tab(&store, &ws.oid, &tab2.oid).unwrap();
378        let ws = get_workspace(&store, &ws.oid).unwrap();
379        assert_eq!(ws.activetabid, tab2.oid);
380
381        // Setting non-existent tab should fail
382        let result = set_active_tab(&store, &ws.oid, "nonexistent");
383        assert!(result.is_err());
384    }
385
386    #[test]
387    fn test_create_and_delete_block() {
388        let store = make_store();
389        let ws = create_workspace(&store, "WS").unwrap();
390        let tab = create_tab(&store, &ws.oid).unwrap();
391
392        let mut meta = MetaMapType::new();
393        meta.insert("view".to_string(), serde_json::json!("term"));
394        let block = create_block(&store, &tab.oid, meta).unwrap();
395
396        let tab = store.must_get::<Tab>(&tab.oid).unwrap();
397        assert_eq!(tab.blockids.len(), 1);
398        assert_eq!(tab.blockids[0], block.oid);
399
400        let loaded = store.must_get::<Block>(&block.oid).unwrap();
401        assert_eq!(loaded.parentoref, format!("tab:{}", tab.oid));
402        assert_eq!(loaded.meta.get("view").unwrap(), "term");
403
404        delete_block(&store, &tab.oid, &block.oid).unwrap();
405        assert!(store.get::<Block>(&block.oid).unwrap().is_none());
406        let tab = store.must_get::<Tab>(&tab.oid).unwrap();
407        assert!(tab.blockids.is_empty());
408    }
409
410    #[test]
411    fn seed_default_layout_creates_three_pane_layout() {
412        // Regression: "Open another window" opened blank because new windows
413        // never received the default layout (only first launch did). This is
414        // the shared primitive both paths now use.
415        // See docs/retro/retro-blank-new-window-2026-06-21.md.
416        let store = make_store();
417        let ws = create_workspace(&store, "WS").unwrap();
418        let tab = create_tab(&store, &ws.oid).unwrap();
419        assert!(tab.blockids.is_empty(), "fresh tab starts blank");
420        let layout = store.must_get::<LayoutState>(&tab.layoutstate).unwrap();
421        assert!(layout.rootnode.is_none(), "fresh tab has no layout");
422
423        seed_default_layout(&store, &tab.oid).unwrap();
424
425        // 3 blocks: agent + sysinfo + swarm.
426        let tab = store.must_get::<Tab>(&tab.oid).unwrap();
427        assert_eq!(tab.blockids.len(), 3, "agent + sysinfo + swarm");
428        let views: Vec<String> = tab
429            .blockids
430            .iter()
431            .map(|bid| {
432                store
433                    .must_get::<Block>(bid)
434                    .unwrap()
435                    .meta
436                    .get("view")
437                    .and_then(|v| v.as_str())
438                    .unwrap_or_default()
439                    .to_string()
440            })
441            .collect();
442        assert!(views.contains(&"agent".to_string()));
443        assert!(views.contains(&"sysinfo".to_string()));
444        assert!(views.contains(&"swarm".to_string()));
445
446        // Layout populated (the regression was new windows getting rootnode=None).
447        let layout = store.must_get::<LayoutState>(&tab.layoutstate).unwrap();
448        assert!(layout.rootnode.is_some(), "rootnode must be populated, not blank");
449        assert_eq!(layout.leaforder.as_ref().unwrap().len(), 3);
450    }
451
452    #[test]
453    fn test_create_and_close_window() {
454        let store = make_store();
455        ensure_initial_data(&store).unwrap();
456
457        let client = get_client(&store).unwrap();
458        let initial_count = client.windowids.len();
459
460        let ws = create_workspace(&store, "WS2").unwrap();
461        let window = create_window(&store, &ws.oid).unwrap();
462
463        // Add to client
464        let mut client = get_client(&store).unwrap();
465        client.windowids.push(window.oid.clone());
466        store.update(&mut client).unwrap();
467
468        close_window(&store, &window.oid).unwrap();
469        let client = get_client(&store).unwrap();
470        assert_eq!(client.windowids.len(), initial_count);
471        assert!(store.get::<Window>(&window.oid).unwrap().is_none());
472    }
473
474    #[test]
475    fn test_focus_window() {
476        let store = make_store();
477        ensure_initial_data(&store).unwrap();
478
479        let ws = create_workspace(&store, "WS2").unwrap();
480        let w2 = create_window(&store, &ws.oid).unwrap();
481        let mut client = get_client(&store).unwrap();
482        client.windowids.push(w2.oid.clone());
483        store.update(&mut client).unwrap();
484
485        // w2 should be last, focus should move it first
486        focus_window(&store, &w2.oid).unwrap();
487        let client = get_client(&store).unwrap();
488        assert_eq!(client.windowids[0], w2.oid);
489    }
490
491    #[test]
492    fn test_switch_workspace() {
493        let store = make_store();
494        ensure_initial_data(&store).unwrap();
495
496        let client = get_client(&store).unwrap();
497        let window_id = &client.windowids[0];
498        let window = store.must_get::<Window>(window_id).unwrap();
499        let old_ws = window.workspaceid.clone();
500
501        let new_ws = create_workspace(&store, "New WS").unwrap();
502        switch_workspace(&store, window_id, &new_ws.oid).unwrap();
503
504        let window = store.must_get::<Window>(window_id).unwrap();
505        assert_eq!(window.workspaceid, new_ws.oid);
506        assert_ne!(window.workspaceid, old_ws);
507    }
508
509    #[test]
510    fn test_resolve_block_id_prefix() {
511        let store = make_store();
512        let ws = create_workspace(&store, "WS").unwrap();
513        let tab = create_tab(&store, &ws.oid).unwrap();
514
515        let meta = MetaMapType::new();
516        let block = create_block(&store, &tab.oid, meta).unwrap();
517        let prefix = &block.oid[..8];
518
519        let resolved = resolve_block_id_from_prefix(&store, &tab.oid, prefix).unwrap();
520        assert_eq!(resolved, block.oid);
521
522        // Non-matching prefix
523        let result = resolve_block_id_from_prefix(&store, &tab.oid, "00000000");
524        assert!(result.is_err());
525    }
526
527    #[test]
528    fn test_list_workspaces() {
529        let store = make_store();
530        create_workspace(&store, "WS1").unwrap();
531        create_workspace(&store, "WS2").unwrap();
532        create_workspace(&store, "WS3").unwrap();
533
534        let all = list_workspaces(&store).unwrap();
535        assert_eq!(all.len(), 3);
536    }
537
538    #[test]
539    fn test_check_and_fix_window_missing_workspace() {
540        let store = make_store();
541        // Create a window pointing to a non-existent workspace
542        let mut window = Window {
543            oid: Uuid::new_v4().to_string(),
544            workspaceid: "nonexistent".to_string(),
545            pos: Point { x: 0, y: 0 },
546            winsize: WinSize {
547                width: 800,
548                height: 600,
549            },
550            meta: MetaMapType::new(),
551            ..Default::default()
552        };
553        store.insert(&mut window).unwrap();
554
555        window::check_and_fix_window(&store, &window.oid).unwrap();
556
557        // Should have created a new workspace and pointed window to it
558        let fixed = store.must_get::<Window>(&window.oid).unwrap();
559        assert_ne!(fixed.workspaceid, "nonexistent");
560        let ws = store.must_get::<Workspace>(&fixed.workspaceid).unwrap();
561        assert_eq!(ws.tabids.len(), 1); // should have created a tab too
562    }
563
564    #[test]
565    fn test_move_block_to_tab() {
566        let store = make_store();
567        let ws = create_workspace(&store, "WS").unwrap();
568        let tab1 = create_tab(&store, &ws.oid).unwrap();
569        let tab2 = create_tab(&store, &ws.oid).unwrap();
570
571        let meta = MetaMapType::new();
572        let block = create_block(&store, &tab1.oid, meta).unwrap();
573
574        // Verify block is in tab1
575        let t1 = store.must_get::<Tab>(&tab1.oid).unwrap();
576        assert_eq!(t1.blockids.len(), 1);
577        assert_eq!(t1.blockids[0], block.oid);
578
579        // Move block from tab1 to tab2
580        move_block_to_tab(&store, &block.oid, &tab1.oid, &tab2.oid, &ws.oid, false).unwrap();
581
582        // tab1 should be empty, tab2 should have the block
583        let t1 = store.must_get::<Tab>(&tab1.oid).unwrap();
584        let t2 = store.must_get::<Tab>(&tab2.oid).unwrap();
585        assert!(t1.blockids.is_empty());
586        assert_eq!(t2.blockids.len(), 1);
587        assert_eq!(t2.blockids[0], block.oid);
588
589        // Block parentoref should point to tab2
590        let b = store.must_get::<Block>(&block.oid).unwrap();
591        assert_eq!(b.parentoref, format!("tab:{}", tab2.oid));
592    }
593
594    #[test]
595    fn test_move_block_to_tab_auto_close() {
596        let store = make_store();
597        let ws = create_workspace(&store, "WS").unwrap();
598        let tab1 = create_tab(&store, &ws.oid).unwrap();
599        let tab2 = create_tab(&store, &ws.oid).unwrap();
600
601        let block = create_block(&store, &tab1.oid, MetaMapType::new()).unwrap();
602
603        // Move with auto_close=true — tab1 should be deleted since it becomes empty
604        move_block_to_tab(&store, &block.oid, &tab1.oid, &tab2.oid, &ws.oid, true).unwrap();
605
606        // tab1 should be deleted
607        assert!(store.get::<Tab>(&tab1.oid).unwrap().is_none());
608
609        // workspace should only have tab2
610        let ws = store.must_get::<Workspace>(&ws.oid).unwrap();
611        assert_eq!(ws.tabids.len(), 1);
612        assert_eq!(ws.tabids[0], tab2.oid);
613    }
614
615    #[test]
616    fn test_move_block_same_tab_noop() {
617        let store = make_store();
618        let ws = create_workspace(&store, "WS").unwrap();
619        let tab = create_tab(&store, &ws.oid).unwrap();
620        let block = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
621
622        // Moving to same tab should be a no-op
623        move_block_to_tab(&store, &block.oid, &tab.oid, &tab.oid, &ws.oid, false).unwrap();
624
625        let t = store.must_get::<Tab>(&tab.oid).unwrap();
626        assert_eq!(t.blockids.len(), 1);
627    }
628
629    #[test]
630    fn test_promote_block_to_tab() {
631        let store = make_store();
632        let ws = create_workspace(&store, "WS").unwrap();
633        let tab = create_tab(&store, &ws.oid).unwrap();
634        let block = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
635
636        // Promote block to new tab
637        let new_tab = promote_block_to_tab(&store, &block.oid, &tab.oid, &ws.oid, false).unwrap();
638
639        // Original tab should be empty
640        let old_tab = store.must_get::<Tab>(&tab.oid).unwrap();
641        assert!(old_tab.blockids.is_empty());
642
643        // New tab should have the block
644        let nt = store.must_get::<Tab>(&new_tab.oid).unwrap();
645        assert_eq!(nt.blockids.len(), 1);
646        assert_eq!(nt.blockids[0], block.oid);
647
648        // Workspace should have both tabs
649        let ws = store.must_get::<Workspace>(&ws.oid).unwrap();
650        assert_eq!(ws.tabids.len(), 2);
651
652        // New tab should be active
653        assert_eq!(ws.activetabid, new_tab.oid);
654    }
655
656    #[test]
657    fn test_reorder_tab() {
658        let store = make_store();
659        let ws = create_workspace(&store, "WS").unwrap();
660        let tab1 = create_tab(&store, &ws.oid).unwrap();
661        let tab2 = create_tab(&store, &ws.oid).unwrap();
662        let tab3 = create_tab(&store, &ws.oid).unwrap();
663
664        // Verify initial order: [tab1, tab2, tab3]
665        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
666        assert_eq!(ws_data.tabids, vec![tab1.oid.clone(), tab2.oid.clone(), tab3.oid.clone()]);
667
668        // Move tab3 to index 0
669        reorder_tab(&store, &ws.oid, &tab3.oid, 0).unwrap();
670
671        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
672        assert_eq!(ws_data.tabids, vec![tab3.oid.clone(), tab1.oid.clone(), tab2.oid.clone()]);
673
674        // Move tab1 to end (index 99 should clamp to len)
675        reorder_tab(&store, &ws.oid, &tab1.oid, 99).unwrap();
676
677        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
678        assert_eq!(ws_data.tabids, vec![tab3.oid.clone(), tab2.oid.clone(), tab1.oid.clone()]);
679    }
680
681    #[test]
682    fn test_move_tab_to_workspace() {
683        let store = make_store();
684        let ws1 = create_workspace(&store, "WS1").unwrap();
685        let ws2 = create_workspace(&store, "WS2").unwrap();
686        let tab1 = create_tab(&store, &ws1.oid).unwrap();
687        let tab2 = create_tab(&store, &ws1.oid).unwrap();
688        let tab3 = create_tab(&store, &ws2.oid).unwrap();
689
690        // Set tab1 as active in ws1
691        set_active_tab(&store, &ws1.oid, &tab1.oid).unwrap();
692
693        // Move tab2 from ws1 to ws2
694        move_tab_to_workspace(&store, &tab2.oid, &ws1.oid, &ws2.oid, None).unwrap();
695
696        let ws1_data = store.must_get::<Workspace>(&ws1.oid).unwrap();
697        let ws2_data = store.must_get::<Workspace>(&ws2.oid).unwrap();
698
699        assert_eq!(ws1_data.tabids, vec![tab1.oid.clone()]);
700        assert!(ws2_data.tabids.contains(&tab2.oid));
701        assert!(ws2_data.tabids.contains(&tab3.oid));
702        // Moved tab becomes active in destination
703        assert_eq!(ws2_data.activetabid, tab2.oid);
704    }
705
706    #[test]
707    fn test_move_tab_to_workspace_last_tab_blocked() {
708        let store = make_store();
709        let ws1 = create_workspace(&store, "WS1").unwrap();
710        let ws2 = create_workspace(&store, "WS2").unwrap();
711        let tab1 = create_tab(&store, &ws1.oid).unwrap();
712        let _tab2 = create_tab(&store, &ws2.oid).unwrap();
713
714        // Should fail — can't move the only tab out
715        let result = move_tab_to_workspace(&store, &tab1.oid, &ws1.oid, &ws2.oid, None);
716        assert!(result.is_err());
717    }
718
719    #[test]
720    fn test_tear_off_block() {
721        let store = make_store();
722        let ws = create_workspace(&store, "WS").unwrap();
723        let tab = create_tab(&store, &ws.oid).unwrap();
724        let block = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
725        let block2 = create_block(&store, &tab.oid, MetaMapType::new()).unwrap();
726
727        // Tear off block (not auto_close because tab still has block2)
728        let new_ws = tear_off_block(&store, &block.oid, &tab.oid, &ws.oid, true).unwrap();
729
730        // Block removed from source tab
731        let tab_data = store.must_get::<Tab>(&tab.oid).unwrap();
732        assert!(!tab_data.blockids.contains(&block.oid));
733        assert!(tab_data.blockids.contains(&block2.oid));
734
735        // New workspace created with the block
736        assert!(!new_ws.oid.is_empty());
737        assert_eq!(new_ws.tabids.len(), 1);
738        let new_tab = store.must_get::<Tab>(&new_ws.tabids[0]).unwrap();
739        assert!(new_tab.blockids.contains(&block.oid));
740
741        // Block parent ref updated
742        let block_data = store.must_get::<Block>(&block.oid).unwrap();
743        assert_eq!(block_data.parentoref, format!("tab:{}", new_tab.oid));
744    }
745
746    #[test]
747    fn test_tear_off_tab() {
748        let store = make_store();
749        let ws = create_workspace(&store, "WS").unwrap();
750        let tab1 = create_tab(&store, &ws.oid).unwrap();
751        let tab2 = create_tab(&store, &ws.oid).unwrap();
752        set_active_tab(&store, &ws.oid, &tab1.oid).unwrap();
753
754        // Tear off tab2
755        let new_ws = tear_off_tab(&store, &tab2.oid, &ws.oid).unwrap();
756
757        // Source workspace no longer has tab2
758        let ws_data = store.must_get::<Workspace>(&ws.oid).unwrap();
759        assert_eq!(ws_data.tabids, vec![tab1.oid.clone()]);
760
761        // New workspace has tab2
762        assert_eq!(new_ws.tabids, vec![tab2.oid.clone()]);
763        assert_eq!(new_ws.activetabid, tab2.oid);
764    }
765
766    #[test]
767    fn test_tear_off_last_tab_blocked() {
768        let store = make_store();
769        let ws = create_workspace(&store, "WS").unwrap();
770        let tab1 = create_tab(&store, &ws.oid).unwrap();
771
772        // Should fail — can't tear off the only tab
773        let result = tear_off_tab(&store, &tab1.oid, &ws.oid);
774        assert!(result.is_err());
775    }
776}